29 — 汹涌的海

Three.js Journey · Bruno Simon · 难度:简单

29 — 汹涌的海 课程海报

简介

现在我们知道了如何使用 shader 并绘制一些图案,让我们好好利用它来创建一个汹涌的海。

我们将为波浪制作动画,并通过 debug 面板来控制这些参数。

Setup

目前,我们只有一个使用 MeshBasicMaterial 的旋转过的平面。geometry 有 128x128 的细分。我们将对顶点进行动画来得到波浪,而这需要相当多的顶点。128x128 可能不够,但如果需要,我们会增大这个值。

基础

让我们把 material 替换为一个 ShaderMaterial:

const waterMaterial = new THREE.ShaderMaterial()

我们的项目配置已经支持 GLSL 文件,但我们需要创建这些文件。

在 /src/shaders/water/vertex.glsl 中创建 vertex shader:

void main()
{
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);
    vec4 viewPosition = viewMatrix * modelPosition;
    vec4 projectedPosition = projectionMatrix * viewPosition;
    gl_Position = projectedPosition;
}

现在在 /src/shaders/water/fragment.glsl 中创建 fragment shader:

void main()
{
    gl_FragColor = vec4(0.5, 0.8, 1.0, 1.0);
}

Three.js 的 WebGLRenderer 要求我们在正确的色彩空间中输出颜色(默认是 sRGB)。这会改善输出的颜色,但这也意味着我们需要在 fragment shader 的末尾添加一些代码,以便将当前的 gl_FragColor 转换为 sRGB 版本。

幸运的是,Three.js 已经提供了这段代码,我们只需在 fragment shader 的最末尾添加下面这一行:

void main()
{
    // ...
    #include <colorspace_fragment>
}

你将在课程后续部分了解到这个 #include 是什么。

最后,在你的 script 中导入这些 shader,并在一个 ShaderMaterial 中使用它们:

// ...

import waterVertexShader from './shaders/water/vertex.glsl'
import waterFragmentShader from './shaders/water/fragment.glsl'

// ...

const waterMaterial = new THREE.ShaderMaterial({
    vertexShader: waterVertexShader,
    fragmentShader: waterFragmentShader
})

你应该得到一个蓝色的平面。如果没有,请检查日志。

如果你是凭记忆完成这一切的,恭喜你,你是个天才。如果不是,这完全正常,你只是需要时间。

大波浪

我们从大波浪开始,以便快速获得显著的效果。还有什么比正弦波更适合用来创建波浪呢?

在 vertex shader 中,让我们基于 x 用 sin(...) 来移动 modelPosition 的 y 值:

vec4 modelPosition = modelMatrix * vec4(position, 1.0);
modelPosition.y += sin(modelPosition.x);

位移和频率应该太高了。与其在 shader 中直接乘以那些凭空出现的数值,我们将使用 uniforms 来更好地控制它们。

让我们从高度(elevation)开始。

高度(Elevation)

向 ShaderMaterial 添加一个 uBigWavesElevation uniform:

const waterMaterial = new THREE.ShaderMaterial({
    vertexShader: waterVertexShader,
    fragmentShader: waterFragmentShader,
    uniforms:
    {
        uBigWavesElevation: { value: 0.2 }
    }
})

我们现在可以在 vertex shader 中获取并使用 uBigWavesElevation uniform:

uniform float uBigWavesElevation;

void main()
{
    // ...
    modelPosition.y += sin(modelPosition.x) * uBigWavesElevation;

    // ...
}

与其直接更新 y 属性,我们应该使用一个名为 elevation 的变量。这在稍后我们给这些波浪着色时会派上用场:

uniform float uBigWavesElevation;

void main()
{
    vec4 modelPosition = modelMatrix * vec4(position, 1.0);

    // Elevation
    float elevation = sin(modelPosition.x) * uBigWavesElevation;
    modelPosition.y += elevation;

    // ...
}

因为现在 elevation 在 JavaScript 中处理,我们可以把它添加到我们的 lil-gui 中:

gui.add(waterMaterial.uniforms.uBigWavesElevation, 'value').min(0).max(1).step(0.001).name('uBigWavesElevation')
lesson demo video preview

频率(Frequency)

现在我们来处理频率。目前,波浪的高度只在 x 轴上变化,但如果能同时控制 x 和 z 轴会更好。

创建一个带有 Vector2 的 uBigWavesFrequency uniform:

const waterMaterial = new THREE.ShaderMaterial({
    vertexShader: waterVertexShader,
    fragmentShader: waterFragmentShader,
    uniforms:
    {
        uBigWavesElevation: { value: 0.2 },
        uBigWavesFrequency: { value: new THREE.Vector2(4, 1.5) } 
    } 
})

在 vertex shader 中获取这个 uniform ——注意,它是一个 vec2—— 并先只把 x 属性应用到 sin(...) 中:

// ...
uniform vec2 uBigWavesFrequency;

void main()
{
    // ...

    float elevation = sin(modelPosition.x * uBigWavesFrequency.x) * uBigWavesElevation;

    // ...
}

因为频率更高,这应该会产生更多的波浪。

让我们使用 uBigWavesFrequency 的第二个值(y)来控制 z 轴上的波浪。我们可以通过将第一个 sin(...) 乘以另一个 sin(...) 来实现:

float elevation = sin(modelPosition.x * uBigWavesFrequency.x) * sin(modelPosition.z * uBigWavesFrequency.y) * uBigWavesElevation;

你也应该在 z 轴上得到波浪。

现在我们可以把这些 x 和 y 属性添加到我们的 lil-gui 中:

gui.add(waterMaterial.uniforms.uBigWavesFrequency.value, 'x').min(0).max(10).step(0.001).name('uBigWavesFrequencyX')
gui.add(waterMaterial.uniforms.uBigWavesFrequency.value, 'y').min(0).max(10).step(0.001).name('uBigWavesFrequencyY')
lesson demo video preview

动画(Animate)

让我们给这些大波浪添加动画。我们将像上一课那样使用经过的时间(elapsed time)来偏移 sin(...) 中的值,从而创建动画。

首先,在 ShaderMaterial 中创建一个 uTime uniform:

const waterMaterial = new THREE.ShaderMaterial({
    // ...
    uniforms:
    {
        uTime: { value: 0 },
        // ...
    } 
})

然后,在 tick function 中更新这个值:

const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Water
    waterMaterial.uniforms.uTime.value = elapsedTime

    // ...
}

在 vertex shader 中,在两个 sin(...) 函数中获取并使用 uTime:

uniform float uTime;
// ...

void main()
{
    // ...

    float elevation = sin(modelPosition.x * uBigWavesFrequency.x + uTime) * sin(modelPosition.z * uBigWavesFrequency.y + uTime) * uBigWavesElevation;

    // ...
}
lesson demo video preview

你现在得到了有动画的波浪。虽然这个速度对汹涌的海来说还可以,但如果能控制它会更好。

让我们创建一个 uBigWavesSpeed uniform 并用它乘以 uTime。为了更简单,我们将使用 float,但如果你想分别控制两个轴的速度,可以使用 vec2。

在 ShaderMaterial 中创建 uBigWavesSpeed uniform 并添加 tweak:

const waterMaterial = new THREE.ShaderMaterial({
    // ...
    uniforms:
    {
        // ...
        uBigWavesSpeed: { value: 0.75 } 
    } 
})

// ...
gui.add(waterMaterial.uniforms.uBigWavesSpeed, 'value').min(0).max(4).step(0.001).name('uBigWavesSpeed')

在 vertex shader 中获取 uBigWavesSpeed uniform,并在两个 sin(...) 函数中用 uTime 乘以它:

// ...
uniform float uBigWavesSpeed;

void main()
{
    // ...

    float elevation = sin(modelPosition.x * uBigWavesFrequency.x + uTime * uBigWavesSpeed) * sin(modelPosition.z * uBigWavesFrequency.y + uTime * uBigWavesSpeed) * uBigWavesElevation;
    
    // ...
}
lesson demo video preview

我们的 elevation 公式太长了。不要犹豫,可以用变量或简单的换行稍微重构一下:

float elevation = sin(modelPosition.x * uBigWavesFrequency.x + uTime * uBigWavesSpeed) *
                  sin(modelPosition.z * uBigWavesFrequency.y + uTime * uBigWavesSpeed) *
                  uBigWavesElevation;

颜色

我们的波浪开始看起来很棒了,但那种统一的蓝色并没有什么帮助。

让我们生成两种颜色,一种用于深处(depth),一种用于表面(surface)。如果你还记得,往 lil-gui 中添加 Three.js 颜色有点复杂。

首先,我们需要在 gui 实例化之后立即创建一个 debugObject:

const gui = new GUI({ width: 340 })
const debugObject = {}

然后,就在 waterMaterial 实例化之前,我们可以把这两种颜色创建为 debugObject 的属性,并在两个新的 uniform 中使用它们,我们将其称为 uDepthColor 和 uSurfaceColor。这些颜色将使用 Color 类:

// Colors
debugObject.depthColor = '#0000ff'
debugObject.surfaceColor = '#8888ff'

// Material
const waterMaterial = new THREE.ShaderMaterial({
    vertexShader: waterVertexShader,
    fragmentShader: waterFragmentShader,
    uniforms:
    {
        // ...
        uDepthColor: { value: new THREE.Color(debugObject.depthColor) },
        uSurfaceColor: { value: new THREE.Color(debugObject.surfaceColor) }
    } 
})

然后我们可以用 addColor 方法把它们添加到我们的 lil-gui 中。我们还需要在颜色变化时用 onChange(...) 更新 waterMaterial 的 uniforms:

debugObject.depthColor = '#0000ff'
debugObject.surfaceColor = '#8888ff'

gui.addColor(debugObject, 'depthColor').onChange(() => { waterMaterial.uniforms.uDepthColor.value.set(debugObject.depthColor) })
gui.addColor(debugObject, 'surfaceColor').onChange(() => { waterMaterial.uniforms.uSurfaceColor.value.set(debugObject.surfaceColor) })
lesson demo video preview

你应该能看到颜色 tweak,但改变它们并不会影响 material。这是因为我们还没有在 shader 中使用 uDepthColor 和 uSurfaceColor uniform。

在 fragment shader 中,首先获取这些颜色:

uniform vec3 uDepthColor;
uniform vec3 uSurfaceColor;

并只使用一种颜色来验证一切正常:

// ...

void main()
{
    gl_FragColor = vec4(uDepthColor, 1.0);
    #include <colorspace_fragment>
}

我们现在需要做的是:如果波浪较低就多用一些 uDepthColor,如果较高就多用一些 uSurfaceColor。

还记得上一课,我们将使用 mix(...)。这个函数需要一个第一个输入、一个第二个输入,以及一个用来决定如何混合前两个输入的值。

正如你能想象的,前两个参数是 uDepthColor 和 uSurfaceColor。但是控制混合的第三个值呢?

我们可以使用 elevation,但不幸的是这个变量在 vertex shader 中。

为了把这个变量传递到 fragment shader ——就像我们在之前的课程中所做的那样—— 我们将使用一个 varying。在 vertex shader 中,创建一个 vElevation varying 并在 main 函数中更新它:

// ...

varying float vElevation;

void main()
{
    // ...

    // Varyings
    vElevation = elevation;
}

在 fragment shader 中获取这个 varying。然后创建一个 color 变量,根据 vElevation 混合 uDepthColor 和 uSurfaceColor:

uniform vec3 uDepthColor;
uniform vec3 uSurfaceColor;

varying float vElevation;

void main()
{
    vec3 color = mix(uDepthColor, uSurfaceColor, vElevation);
    gl_FragColor = vec4(color, 1.0);
    #include <colorspace_fragment>
}
lesson demo video preview

你应该能看到颜色有非常细微的变化。问题在于,根据我们的代码,我们的 vElevation 目前只从 - 0.2 变化到 + 0.2。我们需要找到一种方法来控制这个 vElevation,但只在 fragment shader 中。

让我们添加一些 uniforms!我们将创建一个 uColorOffset 和 uColorMultiplier,并把它们都添加到我们的 lil-gui 中:

const waterMaterial = new THREE.ShaderMaterial({
    vertexShader: waterVertexShader,
    fragmentShader: waterFragmentShader,
    uniforms:
    {
        // ...
        uColorOffset: { value: 0.25 },
        uColorMultiplier: { value: 2 },
    } 
})

// ...
gui.add(waterMaterial.uniforms.uColorOffset, 'value').min(0).max(1).step(0.001).name('uColorOffset')
gui.add(waterMaterial.uniforms.uColorMultiplier, 'value').min(0).max(10).step(0.001).name('uColorMultiplier')

现在在 fragment shader 中获取 uColorOffset 和 uColorMultiplier uniform,基于这两个 uniform 创建一个 mixStrength 变量 ——以便更容易阅读—— 并在 mix(...) 函数中使用该变量:

uniform vec3 uDepthColor;
uniform vec3 uSurfaceColor;
uniform float uColorOffset;
uniform float uColorMultiplier;

varying float vElevation;

void main()
{
    float mixStrength = (vElevation + uColorOffset) * uColorMultiplier;
    vec3 color = mix(uDepthColor, uSurfaceColor, mixStrength);
    
    gl_FragColor = vec4(color, 1.0);
    #include <colorspace_fragment>
}

你获得了更好的渐变。调整这些值来调出你喜欢的颜色:

debugObject.depthColor = '#186691'
debugObject.surfaceColor = '#9bd8ff'

// ...

const waterMaterial = new THREE.ShaderMaterial({
    vertexShader: waterVertexShader,
    fragmentShader: waterFragmentShader,
    uniforms:
    {
        // ...

        uColorOffset: { value: 0.08 },
        uColorMultiplier: { value: 5 }
    }
})

小波浪

对于小波浪,我们将使用 perlin 噪声。我们在上一课中使用了 2D perlin 噪声,在那里我们发送 2D 坐标并返回一个 float 值。这一次,我们将使用 3D perlin。这将使该噪声能够随时间变化,从而得到更真实的结果。

前往与上一课相同的 gist,并复制 Stefan Gustavson 的 Classic Perlin 3D Noise:https://gist.github.com/patriciogonzalezvivo/670c22f3966e662d2f83

或者将下面的代码复制到 vertex shader 中:

// Classic Perlin 3D Noise 
// by Stefan Gustavson
//
vec4 permute(vec4 x)
{
    return mod(((x*34.0)+1.0)*x, 289.0);
}
vec4 taylorInvSqrt(vec4 r)
{
    return 1.79284291400159 - 0.85373472095314 * r;
}
vec3 fade(vec3 t)
{
    return t*t*t*(t*(t*6.0-15.0)+10.0);
}

float cnoise(vec3 P)
{
    vec3 Pi0 = floor(P); // Integer part for indexing
    vec3 Pi1 = Pi0 + vec3(1.0); // Integer part + 1
    Pi0 = mod(Pi0, 289.0);
    Pi1 = mod(Pi1, 289.0);
    vec3 Pf0 = fract(P); // Fractional part for interpolation
    vec3 Pf1 = Pf0 - vec3(1.0); // Fractional part - 1.0
    vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
    vec4 iy = vec4(Pi0.yy, Pi1.yy);
    vec4 iz0 = Pi0.zzzz;
    vec4 iz1 = Pi1.zzzz;

    vec4 ixy = permute(permute(ix) + iy);
    vec4 ixy0 = permute(ixy + iz0);
    vec4 ixy1 = permute(ixy + iz1);

    vec4 gx0 = ixy0 / 7.0;
    vec4 gy0 = fract(floor(gx0) / 7.0) - 0.5;
    gx0 = fract(gx0);
    vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
    vec4 sz0 = step(gz0, vec4(0.0));
    gx0 -= sz0 * (step(0.0, gx0) - 0.5);
    gy0 -= sz0 * (step(0.0, gy0) - 0.5);

    vec4 gx1 = ixy1 / 7.0;
    vec4 gy1 = fract(floor(gx1) / 7.0) - 0.5;
    gx1 = fract(gx1);
    vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
    vec4 sz1 = step(gz1, vec4(0.0));
    gx1 -= sz1 * (step(0.0, gx1) - 0.5);
    gy1 -= sz1 * (step(0.0, gy1) - 0.5);

    vec3 g000 = vec3(gx0.x,gy0.x,gz0.x);
    vec3 g100 = vec3(gx0.y,gy0.y,gz0.y);
    vec3 g010 = vec3(gx0.z,gy0.z,gz0.z);
    vec3 g110 = vec3(gx0.w,gy0.w,gz0.w);
    vec3 g001 = vec3(gx1.x,gy1.x,gz1.x);
    vec3 g101 = vec3(gx1.y,gy1.y,gz1.y);
    vec3 g011 = vec3(gx1.z,gy1.z,gz1.z);
    vec3 g111 = vec3(gx1.w,gy1.w,gz1.w);

    vec4 norm0 = taylorInvSqrt(vec4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
    g000 *= norm0.x;
    g010 *= norm0.y;
    g100 *= norm0.z;
    g110 *= norm0.w;
    vec4 norm1 = taylorInvSqrt(vec4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
    g001 *= norm1.x;
    g011 *= norm1.y;
    g101 *= norm1.z;
    g111 *= norm1.w;

    float n000 = dot(g000, Pf0);
    float n100 = dot(g100, vec3(Pf1.x, Pf0.yz));
    float n010 = dot(g010, vec3(Pf0.x, Pf1.y, Pf0.z));
    float n110 = dot(g110, vec3(Pf1.xy, Pf0.z));
    float n001 = dot(g001, vec3(Pf0.xy, Pf1.z));
    float n101 = dot(g101, vec3(Pf1.x, Pf0.y, Pf1.z));
    float n011 = dot(g011, vec3(Pf0.x, Pf1.yz));
    float n111 = dot(g111, Pf1);

    vec3 fade_xyz = fade(Pf0);
    vec4 n_z = mix(vec4(n000, n100, n010, n110), vec4(n001, n101, n011, n111), fade_xyz.z);
    vec2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y);
    float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x); 
    return 2.2 * n_xyz;
}

这次不需要做修复。

我们现在可以使用 cnoise 函数,并将 vec3 作为参数。

以下是 vec3 的三个值:

elevation += cnoise(vec3(modelPosition.xz, uTime));
lesson demo video preview

这不是预期的结果。首先,波浪速度太快了。所以你必须用 0.2 乘以 uTime:

elevation += cnoise(vec3(modelPosition.xz, uTime * 0.2));
lesson demo video preview

其次,频率太小了。这导致波浪和我们之前创建的大波浪一样大。要增加频率,用 3.0 乘以 modelPosition.xz:

elevation += cnoise(vec3(modelPosition.xz * 3.0, uTime * 0.2));
lesson demo video preview

第三,波浪太高了。让我们通过用 0.15 乘以噪声来减小它:

elevation += cnoise(vec3(modelPosition.xz * 3.0, uTime * 0.2)) * 0.15;
lesson demo video preview

最后,现实中的波浪并没有那么平滑。真实的波浪有圆润的波谷和高耸的波峰。为了实现这个结果,我们可以使用 abs(...) 函数:

elevation += abs(cnoise(vec3(modelPosition.xz * 3.0, uTime * 0.2)) * 0.15);
lesson demo video preview

我们得到的正好与想要的相反,成了圆润的波峰和高耸的波谷。要反转波浪,把 + 替换为 -:

elevation -= abs(cnoise(vec3(modelPosition.xz * 3.0, uTime * 0.2)) * 0.15);
lesson demo video preview

这好多了,但当你观察汹涌海面上的波浪时,它们看起来更加混乱,有着不同且难以预测的频率。

我们需要在更高的频率上应用更多的噪声。我们可以用不同的值重复前一行,但这是使用 for(...) 循环的绝佳机会。

for(...) 循环在 GLSL 中是可以使用的。只要确保使用 float 类型的变量。我们将从 1.0 开始进行 3 次迭代:

for(float i = 1.0; i <= 3.0; i++)
{
}

然后把我们之前的公式移入循环中:

for(float i = 1.0; i <= 3.0; i++)
{
    elevation -= abs(cnoise(vec3(modelPosition.xz * 3.0, uTime * 0.2)) * 0.15);
}

现在,我们应用了 3 次相同的公式,这应该产生相同的波浪,但它们的振幅要明显得多。

让我们根据 i 变量增加频率并减小振幅:

for(float i = 1.0; i <= 3.0; i++)
{
    elevation -= abs(cnoise(vec3(modelPosition.xz * 3.0 * i, uTime * 0.2)) * 0.15 / i);
}

这好多了。也许你没有注意到,但我们几乎看不到那些较小的波浪。这是因为我们的 geometry 缺少顶点。把细分增加到 512x512:

const waterGeometry = new THREE.PlaneGeometry(2, 2, 512, 512)

那代表了很多三角形,但 plane 是 scene 中唯一的 geometry,而且我们几乎所有的动画都在 shader 中完成,这意味着 GPU 承担了繁重的工作。

让我们添加一些 uniform 和 tweak 来控制这些小波浪:

const waterMaterial = new THREE.ShaderMaterial({
    vertexShader: waterVertexShader,
    fragmentShader: waterFragmentShader,
    uniforms:
    {
        // ...

        uSmallWavesElevation: { value: 0.15 },
        uSmallWavesFrequency: { value: 3 },
        uSmallWavesSpeed: { value: 0.2 },
        uSmallIterations: { value: 4 },

        // ...
    }
})

// ...

gui.add(waterMaterial.uniforms.uSmallWavesElevation, 'value').min(0).max(1).step(0.001).name('uSmallWavesElevation')
gui.add(waterMaterial.uniforms.uSmallWavesFrequency, 'value').min(0).max(30).step(0.001).name('uSmallWavesFrequency')
gui.add(waterMaterial.uniforms.uSmallWavesSpeed, 'value').min(0).max(4).step(0.001).name('uSmallWavesSpeed')
gui.add(waterMaterial.uniforms.uSmallIterations, 'value').min(0).max(5).step(1).name('uSmallIterations')

在 vertex shader 中:

uniform float uSmallWavesElevation;
uniform float uSmallWavesFrequency;
uniform float uSmallWavesSpeed;
uniform float uSmallIterations;

// ...

void main()
{
    // ...

    for(float i = 1.0; i <= uSmallIterations; i++)
    {
        elevation -= abs(cnoise(vec3(modelPosition.xz * uSmallWavesFrequency * i, uTime * uSmallWavesSpeed)) * uSmallWavesElevation / i);
    }

    // ...
}
lesson demo video preview

就是这样。

进阶

如果你想更进一步,可以尝试添加泡沫。

你也可以尝试扩大 plane。

添加 fog 将是一个获得更加沉浸式体验的好方法,能增强那种迷失在海中的感觉。不过要小心,添加 fog 会比我们之前看到的更具挑战性,因为你需要自己编写 fog 的代码。